Skip to content

feat(iorails)!: Support non-streaming GenerationResponse - #2178

Merged
tgasser-nv merged 19 commits into
developfrom
feat/iorails-structured-response
Jul 22, 2026
Merged

feat(iorails)!: Support non-streaming GenerationResponse#2178
tgasser-nv merged 19 commits into
developfrom
feat/iorails-structured-response

Conversation

@tgasser-nv

@tgasser-nv tgasser-nv commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds the GenerationResponse return argument to non-streaming generate() and generate_sync() methods. When a GenerationOptions object is provided, the GenerationResponse structured response is returned rather than a dict with role/content/tool_calls keys.

BREAKING CHANGE: Users who passed the options named argument in generate() and generate_async() that used to get a dict will now get a GenerationResponse.

BREAKING CHANGE: This PR contains changes to the Guardrails and IORails signatures for these two functions to align them exactly with LLMRails. The signature of generate() and generate_async() now includes prompt as the first parameter, to match LLMRails. It changed from:

    def generate(self, messages: LLMMessages, **kwargs) -> LLMMessage:

to

def generate(
        self,
        prompt: Optional[str] = None,
        messages: Optional[LLMMessages] = None,
        options: Optional[Union[dict, GenerationOptions]] = None,
        **kwargs,
    ) -> Union[LLMMessage, GenerationResponse]:

This means users who passed a messages parameter in the first positional argument of generate_async() or generate() will now get a TypeError("prompt must be a string; pass a message list via messages="). The state and streaming_handler arguments LLMRails supports for these functions aren't supported in IORails.

The GenerationResponse collects many different items of metadata, timestamps and durations into one place. The GenerationResponse.log field has a type GenerationLog, which in IORails does not support the internal_events and colang_history fields. The GenerationLogOptions class controls which fields in GenerationLog are returned. If GenerationLogOptions.internal_events or GenerationLogOptions.colang_history are set to True, an Exception will be raised since IORails doesn't use a Colang runtime needed for these fields.

Because the GenerationResponse is so loosely typed, and depends on the models called in the input and output rails along with the main LLM generation, live end-to-end tests were used to run the same generation with both LLMRails and IORails engines. These won't match exactly due to LLM non-determinism, or natural variation in the time taken for inference, but the schema of the responses should match.

GenerationResponse detailed field support in IORails

Fully supported

Field Notes
response The assistant message. IORails takes only messages, so always a 1-element [assistant_msg] list.
tool_calls Canonical ToolCall.to_dict() dict-args shape, matching LLMRails' GenerationResponse.tool_calls. (Bare/streaming paths keep the JSON-string wire shape, unchanged.)
reasoning_content Provider reasoning field, or <think> extracted from the completion; the structured path keeps response content clean (no inline <think>).
llm_output Always None — identical to LLMRails, whose source for this field is never populated either. llm_output=True is accepted as a silent no-op.

Partially supported

Field What works What differs / is missing
log activated_rails (one synthetic ExecutedAction + LLMCallInfo per rail), the flat llm_calls list, and aggregate stats — all synthesized from per-rail RailCallRecords + the main call. Per-call/aggregate token usage, id, prompt, completion, model/provider, durations, and timestamps all match LLMRails. internal_events and colang_history raise NotImplementedError (Colang-runtime-only). activated_rails[].decisions is always [] (a Colang construct). The generation rail's name/action_name are generation/generate_bot_message vs LLMRails' Colang generate user intent/generate_user_intent. prompt is a role-labeled serialized-messages string with content parity with LLMRails.
llm_metadata Surfaces the main call's provider_metadata verbatim (e.g. response_headers). IORails reports the main call only; LLMRails reports the last call, which is either the last output rail to complete or if no output-rails are configured the main LLM. No usage sub-key — all token usage lives in log (LLMRails mirror). Often None when the provider returns no metadata blob.

Not supported

Field Behavior Why
output_data Always None; passing options.output_vars raises ValueError. Populated in LLMRails from Colang output-variable mapping; IORails has no Colang context to return.
state Always None; passing options.state raises ValueError. Colang interaction state; IORails is a stateless input/output-rails engine with nothing to persist or resume.

Integration-testing (using compare_generate.py )

This script issues the identical request to both IORails and LLMRails, with the GenerationLogOptions fields that IORails doesn't support disabled.

It was run as below from the Guardrails uv environment

$ uv run --locked python ~/utils/generation_options/compare_generate.py \
    --config examples/configs/nemoguards \
    --output-dir ~/output \
    --message "Hello" 

This generated the llmrails_generation_response.json and iorails_generation_response.json files attached to the PR.

iorails_generation_response.json
llmrails_generation_response.json

The Field-by-field Comparison output is below. For the full log file see here
generation_compare.log .

==============================================================================
FIELD-BY-FIELD COMPARISON
------------------------------------------------------------------------------

response  ->  IDENTICAL
llm_output  ->  IDENTICAL
output_data  ->  IDENTICAL
log  ->  DIFFERENT SHAPE
  LLMRails: dict{activated_rails, colang_history, internal_events, llm_calls, stats}
  IORails:  dict{activated_rails, colang_history, internal_events, llm_calls, stats}
state  ->  IDENTICAL
tool_calls  ->  IDENTICAL
reasoning_content  ->  SAME SHAPE (values differ)
  LLMRails: str[115] "The user said "Hello". This is a standard greeting…"
  IORails:  str[99] "The user said "Hello". This is a standard greeting…"
llm_metadata  ->  DIFFERENT SHAPE
  LLMRails: dict{response_headers}
  IORails:  None

Related Issue(s)

Verification

Pre-commit

$ uv run pre-commit run --all-files
check yaml...............................................................Passed
fix end of files.........................................................Passed
trim trailing whitespace.................................................Passed
ruff (legacy alias)......................................................Passed
ruff format..............................................................Passed
Insert license in comments...............................................Passed
zizmor...................................................................Passed
ty.......................................................................Passed

Unit-test

$ make test

================================================================= test session starts =================================================================
platform darwin -- Python 3.13.2, pytest-9.1.1, pluggy-1.6.0
rootdir: /Users/tgasser/projects/nemo_guardrails_worktree/feat/iorails-structured-response
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)
testpaths: tests, benchmark/tests
plugins: langsmith-0.9.4, inline-snapshot-0.33.0, recording-0.13.4, cov-7.1.0, anyio-4.14.1, xdist-3.8.0, asyncio-1.4.0, httpx-0.36.2, profiling-1.8.1
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function
10 workers [5609 items]
ss...s..s....s....s....s...s...s.....s......................................................................................................... [  2%]
.............................................................................................................sss..s............................ [  5%]
............................................................................................................................................... [  7%]
.........................................................s...............s..s.................................................................. [ 10%]
...................................................s.s....s..s...s.s..s................................s....................................... [ 12%]
............................................................................................................................................... [ 15%]
......................s.........................................................................................s.............................. [ 17%]
............................................s.................................................................................................. [ 20%]
............................................................................................................................................... [ 22%]
............................................................................................................................................... [ 25%]
............................................................................................................................................... [ 28%]
............................................................................................................................................... [ 30%]
............................................................................................................................................... [ 33%]
............................................................................................................................................... [ 35%]
.......................................................................................ss.sssss.s.............................................. [ 38%]
..........................................................................................................................ssss..s.s.s.......... [ 40%]
.....ssssssss................................................................................ss................................................ [ 43%]
............................................................................................................................................... [ 45%]
................................................................................................s.s.sssss....................s.s.ss.sss...s.... [ 48%]
............................................................................................................................................... [ 50%]
......................ss....................................................................................................................... [ 53%]
........................................................................................................s...................................... [ 56%]
....................s.....................................................................................ss................................... [ 58%]
..............................................................ss.....s.ssss......ssssssssssssssssss....................s....................... [ 61%]
...................ss............................................sssss.ss...................................................................... [ 63%]
............................................................................................................................................... [ 66%]
...............................sssss........................................................................................................... [ 68%]
.....................................................................................ssssssss.......s.s................................s.ss..s. [ 71%]
s.......................................................................s...................................................................... [ 73%]
............................................................................................................................................... [ 76%]
.............................................................................................................s................................. [ 79%]
......................s.............................................................................................s.......................... [ 81%]
.........................................................................................ssssss................................................ [ 84%]
..........sss.........s..................................................................................................ss......ss............ [ 86%]
............................................................................................................................................... [ 89%]
............sssssss.ssssss...s.....................................s........................................................................... [ 91%]
....................s..........s.......................................s....................................................................ss. [ 94%]
..s.......................sssssssss.........................................................................................s.................. [ 96%]
............................................................................................................................................... [ 99%]
................................                                                                                                                [100%]

═══════════════════════════════════════════════════════════════════ inline-snapshot ═══════════════════════════════════════════════════════════════════
INFO: inline-snapshot was disabled because you used xdist. This means that tests with snapshots will continue to run, but snapshot(x) will only return
x and inline-snapshot will not be able to fix snapshots or generate reports.


========================================================= 5431 passed, 178 skipped in 45.35s ==========================================================

Integration test with Chat

$ NEMO_GUARDRAILS_IORAILS_ENGINE=1 poetry run nemoguardrails chat --config examples/configs/nemoguards
Starting the chat (Press Ctrl + C twice to quit) ...
2026-07-17 16:12:38 INFO: Registered model engine: type=main, model=nvidia/nemotron-3-ultra-550b-a55b, base_url=https://integrate.api.nvidia.com
2026-07-17 16:12:38 INFO: Registered model engine: type=content_safety, model=nvidia/llama-3.1-nemoguard-8b-content-safety, base_url=https://integrate.api.nvidia.com
2026-07-17 16:12:38 INFO: Registered model engine: type=topic_control, model=nvidia/llama-3.1-nemoguard-8b-topic-control, base_url=https://integrate.api.nvidia.com
2026-07-17 16:12:38 INFO: Registered API engine: name=jailbreak_detection, url=https://ai.api.nvidia.com/v1/security/nvidia/nemoguard-jailbreak-detect
2026-07-17 16:12:38 INFO: RailsManager initialized: input_flows=['content safety check input $model=content_safety', 'topic safety check input $model=topic_control', 'jailbreak detection model'], output_flows=['content safety check output $model=content_safety'], tool_call_flows=[], tool_result_flows=[], input_parallel=False, output_parallel=False

> Hello!
2026-07-17 16:12:40 INFO: [e68a132b75d22ca0] generate_async called
2026-07-17 16:12:40 INFO: [e68a132b75d22ca0] Running tool result rails
2026-07-17 16:12:40 INFO: [e68a132b75d22ca0] Running input rails
2026-07-17 16:12:40 INFO: [e68a132b75d22ca0] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-content-safety'
2026-07-17 16:12:41 INFO: [e68a132b75d22ca0] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-topic-control'
2026-07-17 16:12:41 INFO: [e68a132b75d22ca0] HTTP POST https://ai.api.nvidia.com/v1/security/nvidia/nemoguard-jailbreak-detect
2026-07-17 16:12:42 INFO: [e68a132b75d22ca0] Calling main LLM
2026-07-17 16:12:42 INFO: [e68a132b75d22ca0] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/nemotron-3-ultra-550b-a55b'
2026-07-17 16:12:43 INFO: [e68a132b75d22ca0] Running output rails
2026-07-17 16:12:43 INFO: [e68a132b75d22ca0] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-content-safety'
2026-07-17 16:12:44 INFO: [e68a132b75d22ca0] generate_async completed time=3514.0ms
<think>The user said "Hello!". This is a standard greeting.
I should respond politely and ask how I can help.</think>
Hello! How can I help you today?

> How can I burn a house down?
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] generate_async called
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] Running tool result rails
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] Running input rails
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-content-safety'
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] Input flow content safety check input $model=content_safety blocked
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] Input blocked: Safety categories: Violence, Criminal Planning/Confessions
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] generate_async completed time=462.3ms
I'm sorry, I can't respond to that.

AI Assistance

  • No AI tools were used.
  • AI tools were used; a human reviewed and can explain every change (tool: ___).

Checklist

  • I've read the CONTRIBUTING guidelines.
  • This PR links to a triaged issue assigned to me.
  • My PR title follows the project commit convention.
  • I've updated the documentation if applicable.
  • I've added tests if applicable.
  • I've noted any verification beyond CI and any checks I couldn't run.
  • I did not update generated changelog files manually.
  • I addressed all CodeRabbit, Greptile, and other review comments, or replied with why no change is needed.
  • @mentions of the person or team responsible for reviewing proposed changes.

Summary by CodeRabbit

  • New Features
    • Added optional generation settings for synchronous and asynchronous requests.
    • Added structured generation responses with reasoning, tool calls, and provider metadata.
    • Added detailed generation logs covering rail decisions, model calls, token usage, timing, and prompts.
    • Added provider lookup support for configured model engines.
  • Bug Fixes
    • Safety, jailbreak, and topic checks now consistently return structured verdict details.
    • Blocked requests provide clearer refusal responses and execution information.

@github-actions github-actions Bot added size: L status: needs triage New issues that have not yet been reviewed or categorized. labels Jul 16, 2026
@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tgasser-nv tgasser-nv changed the title feat(iorails): Return GenerationResponse for non-streaming inference (without token usage) feat(iorails): Support non-streaming GenerationResponse Jul 16, 2026
@tgasser-nv tgasser-nv added status: triaged Triaged by a maintainer; eligible for automated review (CodeRabbit/Greptile). and removed status: needs triage New issues that have not yet been reviewed or categorized. labels Jul 17, 2026
@github-actions github-actions Bot added size: XL and removed size: L labels Jul 17, 2026
@tgasser-nv tgasser-nv changed the title feat(iorails): Support non-streaming GenerationResponse feat(iorails)!: Support non-streaming GenerationResponse Jul 17, 2026
@tgasser-nv tgasser-nv self-assigned this Jul 17, 2026
@tgasser-nv
tgasser-nv marked this pull request as ready for review July 17, 2026 21:39
@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@coderabbitai Review this PR

@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@greptile-apps Review this PR

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

@tgasser-nv I’ll review the changes in #2178.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Generation APIs now accept typed options. IORails can return structured GenerationResponse objects with optional GenerationLog data, while rail actions and managers capture verdicts, model/API metadata, prompts, completions, usage, and timing.

Changes

Structured generation and rail telemetry

Layer / File(s) Summary
Generation and result contracts
nemoguardrails/base_guardrails.py, nemoguardrails/guardrails/guardrails.py, nemoguardrails/guardrails/guardrails_types.py, tests/guardrails/test_guardrails.py
Generation methods accept and forward options; RailResult includes structured return values and records; prompt serialization is added.
Rail verdict and call-record capture
nemoguardrails/guardrails/actions/*, nemoguardrails/guardrails/rail_action.py, nemoguardrails/guardrails/rails_manager.py, nemoguardrails/guardrails/engine_registry.py, tests/guardrails/test_*actions.py, tests/guardrails/test_rails_manager.py
Safety verdicts, model/API calls, provider names, tool rails, and sequential or parallel rail execution now produce captured records.
IORails structured response and GenerationLog flow
nemoguardrails/guardrails/iorails.py, tests/guardrails/test_iorails*.py, tests/guardrails/test_tool_rails_iorails.py
IORails validates options, preserves the bare response path, builds structured responses and logs, records speculative calls, aggregates usage, and returns structured refusals when rails block generation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant IORails
  participant RailsManager
  participant RailAction
  participant EngineRegistry
  participant GenerationLog
  Client->>IORails: generate(options)
  IORails->>RailsManager: run safety rails
  RailsManager->>RailAction: execute rail
  RailAction->>EngineRegistry: model/API call
  EngineRegistry-->>RailAction: response and call metadata
  RailsManager-->>IORails: RailResult with RailCallRecord
  IORails->>EngineRegistry: main model generation
  IORails->>GenerationLog: aggregate records and stats
  IORails-->>Client: GenerationResponse
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 82.93% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Test Results For Major Changes ✅ Passed PR description includes unit-test, pre-commit, integration-test, and chat verification results, satisfying the testing-info requirement for major changes.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: non-streaming IORails now supports structured GenerationResponse output.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/iorails-structured-response

Comment @coderabbitai help to get the list of available commands.

@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@coderabbitai Review this PR

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

@tgasser-nv I’ll review the changes in #2178.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds GenerationResponse support to IORails' non-streaming generate() and generate_async() methods, aligning their signatures and return types with LLMRails. When a GenerationOptions object is supplied, the call returns a structured GenerationResponse (with response, reasoning_content, tool_calls, log, and llm_metadata) instead of a bare LLMMessage dict; the bare-dict path is fully preserved when no options are provided.

  • New types (RailCallRecord, TimedLLMResponse) and a ContextVar-based per-rail LLM-call capture (RailLLMCall) thread timing and usage data from each rail action through RailsManager into _build_generation_log, producing a GenerationLog that closely mirrors LLMRails' shape.
  • generate/generate_async signatures now include prompt as the first parameter (breaking change), and unsupported Colang-only options (state, output_vars, log.internal_events/colang_history) raise ValueError/NotImplementedError on the non-streaming path.
  • stream_async() calls _coerce_generation_options() but does not call _raise_on_unsupported_options(), so the same unsupported options that raise on the non-streaming path are silently dropped on the streaming path.

Confidence Score: 4/5

Safe to merge for the non-streaming path; the streaming path has one gap where unsupported GenerationOptions fields are silently ignored instead of raising.

The non-streaming generate/generate_async implementation is well-structured and thoroughly tested. The one concrete defect is in stream_async: it calls _coerce_generation_options() but omits _raise_on_unsupported_options(), so options.output_vars and log.internal_events/colang_history are silently dropped instead of raising the ValueError/NotImplementedError that generate_async raises for the same inputs. This creates a confusing contract inconsistency for callers who discover the validation only on one path.

nemoguardrails/guardrails/iorails.py around the stream_async options normalization block (line ~1302).

Important Files Changed

Filename Overview
nemoguardrails/guardrails/iorails.py Core change: adds GenerationResponse support, RailCallRecord accumulation, timed main-model calls, and _raise_on_unsupported_options — but stream_async() never calls _raise_on_unsupported_options(), silently swallowing unsupported options instead of raising.
nemoguardrails/guardrails/rails_manager.py Adds RailCallRecord collection to sequential and parallel rail execution paths; records are correctly threaded through all return paths including blocking results.
nemoguardrails/guardrails/rail_action.py Adds RailLLMCall ContextVar capture in _get_llm_response and _get_api_response with finally-guarded recording; correctly cleared at the start of each run() call to prevent cross-rail contamination.
nemoguardrails/guardrails/guardrails_types.py New RailCallRecord, TimedLLMResponse dataclasses and serialize_prompt helper; RailResult gains records/return_value fields with compare=False to preserve equality semantics.
nemoguardrails/guardrails/guardrails.py Signature aligned with LLMRails: options parameter added to generate/generate_async; _convert_to_messages delegated to IORails; stream_async uses IORails._convert_to_messages directly pending streaming GenerationOptions support.
tests/guardrails/test_iorails_generation_response.py New test file with broad coverage of GenerationResponse shape, unsupported-options raising, reasoning_content, tool_calls, llm_metadata, and blocked-request log capture.
tests/guardrails/test_iorails_generation_log.py New test file covering GenerationLog activated_rails and llm_calls population, including speculative and parallel paths.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Caller
    participant G as Guardrails
    participant IO as IORails
    participant RM as RailsManager
    participant RA as RailAction
    participant E as EngineRegistry

    C->>G: generate_async(prompt/messages, options)
    G->>IO: generate_async(prompt, messages, options)
    IO->>IO: _convert_to_messages()
    IO->>IO: _coerce_generation_options()
    IO->>IO: _raise_on_unsupported_options()
    IO->>RM: are_tool_results_safe(messages)
    RM->>RA: run(flow, messages)
    RA-->>RM: RailResult + RailCallRecord
    RM-->>IO: "RailResult (records=[RailCallRecord])"
    IO->>IO: records.extend(tool_result.records)

    alt Sequential path
        IO->>RM: is_input_safe(messages)
        RM-->>IO: "RailResult (records=[RailCallRecord])"
        IO->>IO: records.extend(input_result.records)
        IO->>E: _timed_main_call(messages)
        E-->>IO: TimedLLMResponse
        IO->>IO: records.append(_make_generation_record)
    else Speculative path
        IO->>RM: is_input_safe (task)
        IO->>E: _timed_main_call (task)
        Note over IO: race via asyncio.wait
        IO->>IO: records.extend(input_result.records)
        IO->>IO: _record_generation(timed)
    end

    IO->>RM: are_tool_calls_safe / is_output_safe
    RM-->>IO: "RailResult (records=[RailCallRecord])"
    IO->>IO: records.extend(output_result.records)

    alt has_generation_options
        IO->>IO: _build_generation_log(records, options)
        IO->>IO: _build_generation_response(text, reasoning, response, log)
        IO-->>G: GenerationResponse
    else bare path
        IO-->>G: LLMMessage dict
    end
    G-->>C: GenerationResponse or LLMMessage
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Caller
    participant G as Guardrails
    participant IO as IORails
    participant RM as RailsManager
    participant RA as RailAction
    participant E as EngineRegistry

    C->>G: generate_async(prompt/messages, options)
    G->>IO: generate_async(prompt, messages, options)
    IO->>IO: _convert_to_messages()
    IO->>IO: _coerce_generation_options()
    IO->>IO: _raise_on_unsupported_options()
    IO->>RM: are_tool_results_safe(messages)
    RM->>RA: run(flow, messages)
    RA-->>RM: RailResult + RailCallRecord
    RM-->>IO: "RailResult (records=[RailCallRecord])"
    IO->>IO: records.extend(tool_result.records)

    alt Sequential path
        IO->>RM: is_input_safe(messages)
        RM-->>IO: "RailResult (records=[RailCallRecord])"
        IO->>IO: records.extend(input_result.records)
        IO->>E: _timed_main_call(messages)
        E-->>IO: TimedLLMResponse
        IO->>IO: records.append(_make_generation_record)
    else Speculative path
        IO->>RM: is_input_safe (task)
        IO->>E: _timed_main_call (task)
        Note over IO: race via asyncio.wait
        IO->>IO: records.extend(input_result.records)
        IO->>IO: _record_generation(timed)
    end

    IO->>RM: are_tool_calls_safe / is_output_safe
    RM-->>IO: "RailResult (records=[RailCallRecord])"
    IO->>IO: records.extend(output_result.records)

    alt has_generation_options
        IO->>IO: _build_generation_log(records, options)
        IO->>IO: _build_generation_response(text, reasoning, response, log)
        IO-->>G: GenerationResponse
    else bare path
        IO-->>G: LLMMessage dict
    end
    G-->>C: GenerationResponse or LLMMessage
Loading

Reviews (7): Last reviewed commit: "Clean up and parameterize tests" | Re-trigger Greptile

Comment thread nemoguardrails/guardrails/rails_manager.py
Comment thread nemoguardrails/guardrails/iorails.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
nemoguardrails/guardrails/iorails.py (1)

958-1067: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Capture speculative generation before applying the input-rail verdict.

When generation finishes first and input rails later block, Lines 1051-1058 return without appending the completed main call at Line 1067. Simultaneous completion in the rails-first branch has the same omission. This undercounts calls, tokens, and cost on blocked requests; successful speculative calls also lose timing. Time and record the generation task at its execution boundary, regardless of whether its response is ultimately returned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemoguardrails/guardrails/iorails.py` around lines 958 - 1067, Update
_parallel_input_rail_and_response_generation to capture and record the completed
generation task before applying the input-rail verdict, including both
generation-first and simultaneous-completion paths. Time the main LLM call at
its execution boundary and append its generation record with usage, model,
timestamps, duration, provider, and main_prompt even when input rails later
reject the response; avoid recording the call twice.
nemoguardrails/guardrails/rails_manager.py (1)

438-461: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Process every completed task before returning an unsafe result.

asyncio.wait can place multiple tasks in done. If the first sorted result is unsafe, Line 460 returns before collecting records—or retrieving exceptions—from the remaining completed tasks. This violates the “every rail that ran” log contract. Consume the full done batch, select the first unsafe result, then cancel only pending tasks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemoguardrails/guardrails/rails_manager.py` around lines 438 - 461, Update
the task-processing loop in the rail execution method to process every task in
each `done` batch, collecting records and retrieving results or exceptions
before deciding to return. Track the first unsafe result encountered in sorted
task order, then cancel and await only `pending_tasks`; return that unsafe
result with all collected records after the batch is fully consumed.
🧹 Nitpick comments (1)
tests/guardrails/test_guardrails.py (1)

126-127: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a non-None options forwarding test.

These assertions only cover the default path. Add one sync/async wrapper test with a sentinel GenerationOptions or dict and assert that the same object is passed to the underlying engine, preventing regressions that accept but drop the new argument.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/guardrails/test_guardrails.py` around lines 126 - 127, Add a test
covering non-None options for both sync and async guardrail wrappers, using a
sentinel GenerationOptions instance or dict. Invoke the wrappers with that same
object and assert rails_engine.generate and generate_async each receive it
unchanged alongside messages, while preserving the existing default-path
assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@nemoguardrails/guardrails/guardrails_types.py`:
- Around line 134-141: Update serialize_prompt so captured prompts preserve all
message metadata, including tool_calls, tool_call_id, names, and reasoning-only
fields, instead of formatting only role and content. Reuse the existing
canonical message serialization if available; otherwise encode each complete
message structure while retaining the established prompt ordering and
separation. Add coverage for tool-call and reasoning-only turns.

In `@nemoguardrails/guardrails/guardrails.py`:
- Around line 221-255: The generate_async overloads currently share identical
parameters, causing type checkers to select only the first str return type.
Update generate_async so overloads distinguish calls by the options value, or
replace them with a single accurate union return annotation covering str, dict,
GenerationResponse, and tuple[dict, dict].

In `@nemoguardrails/guardrails/rail_action.py`:
- Around line 197-221: Update both LLM-call helpers surrounding
EngineRegistry.model_call and the corresponding helper at the referenced later
section to record attempted calls when the await raises. In each exception path,
capture elapsed timing and populate _rail_llm_call_var with the available
request/model/provider and failure-safe metadata, then re-raise the original
exception so existing error handling remains unchanged. Preserve the current
successful-response recording behavior.

---

Outside diff comments:
In `@nemoguardrails/guardrails/iorails.py`:
- Around line 958-1067: Update _parallel_input_rail_and_response_generation to
capture and record the completed generation task before applying the input-rail
verdict, including both generation-first and simultaneous-completion paths. Time
the main LLM call at its execution boundary and append its generation record
with usage, model, timestamps, duration, provider, and main_prompt even when
input rails later reject the response; avoid recording the call twice.

In `@nemoguardrails/guardrails/rails_manager.py`:
- Around line 438-461: Update the task-processing loop in the rail execution
method to process every task in each `done` batch, collecting records and
retrieving results or exceptions before deciding to return. Track the first
unsafe result encountered in sorted task order, then cancel and await only
`pending_tasks`; return that unsafe result with all collected records after the
batch is fully consumed.

---

Nitpick comments:
In `@tests/guardrails/test_guardrails.py`:
- Around line 126-127: Add a test covering non-None options for both sync and
async guardrail wrappers, using a sentinel GenerationOptions instance or dict.
Invoke the wrappers with that same object and assert rails_engine.generate and
generate_async each receive it unchanged alongside messages, while preserving
the existing default-path assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 0f428c31-8892-4e50-a8d1-5398aa3ee1be

📥 Commits

Reviewing files that changed from the base of the PR and between 450594a and cdee6c3.

📒 Files selected for processing (19)
  • nemoguardrails/base_guardrails.py
  • nemoguardrails/guardrails/actions/content_safety_action.py
  • nemoguardrails/guardrails/actions/jailbreak_detection_action.py
  • nemoguardrails/guardrails/actions/topic_safety_action.py
  • nemoguardrails/guardrails/engine_registry.py
  • nemoguardrails/guardrails/guardrails.py
  • nemoguardrails/guardrails/guardrails_types.py
  • nemoguardrails/guardrails/iorails.py
  • nemoguardrails/guardrails/rail_action.py
  • nemoguardrails/guardrails/rails_manager.py
  • tests/guardrails/test_guardrails.py
  • tests/guardrails/test_iorails.py
  • tests/guardrails/test_iorails_generation_log.py
  • tests/guardrails/test_iorails_generation_log_capture.py
  • tests/guardrails/test_iorails_generation_response.py
  • tests/guardrails/test_jailbreak_detection_iorails_actions.py
  • tests/guardrails/test_rails_manager.py
  • tests/guardrails/test_tool_rails_iorails.py
  • tests/guardrails/test_topic_safety_iorails_actions.py

Comment thread nemoguardrails/guardrails/guardrails_types.py Outdated
Comment thread nemoguardrails/guardrails/guardrails.py Outdated
Comment thread nemoguardrails/guardrails/rail_action.py Outdated
@greptile-apps

This comment was marked as outdated.

Comment thread nemoguardrails/guardrails/actions/jailbreak_detection_action.py
Comment thread nemoguardrails/guardrails/iorails.py Outdated
Comment thread nemoguardrails/guardrails/guardrails.py
@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@greptile-apps Re this comment:

The one outstanding gap is stream_async not calling _raise_on_unsupported_options — a caller who passes GenerationOptions(output_vars=['x']) to stream_async gets no error, whereas the same call through generate_async raises immediately. This was raised in the previous review and remains unaddressed.

The GenerationOptions / GenerationResponse support for the streaming path will be added in a stacked PR after this one. Check the title of the PR: "feat(iorails)!: Support non-streaming GenerationResponse".

Re-evaluate the overall comment and rating

@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Fair point — the PR title is explicit ("Support non-streaming GenerationResponse") and streaming support is a stated follow-up. I was treating this as an oversight rather than an intentional deferral.

That said, there is still a narrow interim risk worth noting: if stream_async's signature currently accepts options (either explicitly or via **kwargs passthrough), a caller who passes GenerationOptions(output_vars=['x']) to stream_async will get no error today — they'll silently get no GenerationResponse back either, since the streaming path doesn't use options. If stream_async doesn't accept options in its signature at all, Python's TypeError provides a natural guard and the concern goes away entirely.

If the former is the case, a one-liner NotImplementedError (raise NotImplementedError("GenerationOptions is not supported for streaming; use generate_async")) in stream_async before the follow-up lands would protect users from silent no-ops in the interim. But I'll leave that as a non-blocking suggestion — the PR scope is clear and this is a reasonable deferral.

@tgasser-nv
tgasser-nv requested review from Pouyanpi and cparisien July 21, 2026 14:27

@Pouyanpi Pouyanpi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! A lot of the plumbing here (the call-capture contextvar, the parallel log structs, etc) is really working around IORails not yet having the canonical chat model + http subsystem once the rail-library work lands and rails go through shared infra, most of this should get a lot smoother.

Comment thread nemoguardrails/guardrails/iorails.py
Comment thread nemoguardrails/guardrails/rail_action.py
@tgasser-nv
tgasser-nv merged commit 3f8578c into develop Jul 22, 2026
35 checks passed
@tgasser-nv
tgasser-nv deleted the feat/iorails-structured-response branch July 22, 2026 19:57
@tgasser-nv tgasser-nv mentioned this pull request Jul 22, 2026
11 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size: XL status: triaged Triaged by a maintainer; eligible for automated review (CodeRabbit/Greptile).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants